二维网格问题的解法

经常有面试问题是二维网格问题,比如迷宫问题、迷宫最短路径问题、N皇后问题等等。求解时主要有以下要素:

  • 遍历:对每个格子(位置)进行遍历。
  • 搜索相邻的格子。

总体的搜索可以有深度优先(DFS)和广度优先(BFS)之分。

根据我自己的经验,在有限的时间内解答这类问题时,要注意以下几点:

二维数组的行列顺序

二维数组是按照先行后列的顺序存储的:

void func(int[][] data) {
    if (data == null || data.length = 0 || data[0].length == 0) {
        // ...
        return;
    }

    int rows = data.length;
    int cols = data[0].length;

    for (int r = 0; i < rows; ++r) {
        // 或者更好的写法是:c < data[r].length
        // 但这类问题一般都是个矩形区域,因此把cols提前定好,代码更简洁
        for (int c = 0; c < cols; ++c) {
            data[r][c] = 0;
        }
    }

    // ...
}

循环变量名称

循环变量名称似乎根本不是个问题,一般会用i和j来表示。但在有限时间而且缺乏IDE帮助debug的情况下,对于二维格子问题,很容易引起思维混乱。我自己就有一次在线测试时,因为把行和列弄颠倒了,导致时间到了还无法把程序调通。后来自己“复盘”的时候,才发现原因所在。

因此,建议把变量名称弄得一看就懂:

  • 行数和列数:rows,cols
  • 表示行和列的循环变量名:r,c

例如:

for (int r = 0; r < rows; ++r) {
    for (int c = 0; c < cols; ++c) {
        data[r][c] = 0;
    }
}

搜索相邻的格子

一般我会按东西南北四个方向编写:

// N
if (r > 0) {
    backtrack(r - 1, c);
}

// S
if (r < rows - 1) {
    backtrack(r + 1, c);
}

// W
if (c > 0) {
    backtrack(r, c - 1);
}

// E
if (c < cols - 1) {
    backtrack(r, c + 1);
}

这样看似很有效率,但很啰嗦,而且在情急之下容易犯错。我经常会因为拷贝粘贴的代码中没改完全而导致程序不能通过,而之后要用肉眼去观察一遍来找出错误的话,很费时间。因此,在效率没有多少区别的情况下,程序越简短越好。

//                                        N   S   W   E
private static final int[] rowOffsets = {-1,  1,  0,  0};
private static final int[] colOffsets = { 0,  0, -1,  1};

private void backtrack(int[][] data, int[][] visited, int r, int c) {
    // ...

    int rows = data.length;
    int cols = data[0].length;
    BiPredicate<Integer, Integer> isValid = (row, col) ->
            row >= 0 && row < rows && col >= 0 && col < cols;

    // 尝试搜索相邻的格子
    for (int i = 0; i < rowOffsets.length; ++i) {
        int r1 = r + rowOffset[i];
        int c1 = c + colOffset[i];

        if (isValid.test(r1, c1) && !visited[r1][c1]) {
            backtrack(data, visited, r1, c1);
        }
    }

    // ...
}

有人喜欢在枚举相邻格子时,按东南西北的顺序转一圈。在我看来似乎没什么必要。最重要的是简单且不易弄错,所以我都是选择按北、南、东、西的顺序轮转。当然,每个人习惯不同,可以选择自己最舒适的顺序。

总结:对于编程来说,最好是从一开始就不要把bug放进去,否则要找出来就需要花费成倍的时间。因此,要选择各种一看就懂的方式写代码,防止写到中间自己都迷糊了。另外,为了避免查找bug的时间过长,应该尽量写简短的代码(当然不是那种为了短而短的十分缺乏可读性的那种)。

用于算法题的常用Java工具

Array & List

// 排序
int[] array = {3, 4, 1, 2, 5};
Arrays.sort(array);    // T = O(n * log2(n))
List<Integer> list = Arrays.asList(array);
Collections.sort(list);  // 使用ArrayList的话,T = O(n * log2(n))
list.sort(null);         // 上面的函数调用这个函数,其内部实现方法是先把列表转换为数组再排序
                         // 因为如果是链表,直接排序的时间复杂度为O((n ^ 2) * log2(n))。

// array和list转换
List<String> list = Arrays.asList(array);
String[] array = list.toArray(new String[0]);

// max & min
Map<String, Integer> map = ...;
// 找出具有最大value值的key
String max = Collections.max(
        map.entrySet(),
        Comparator.comparingInt(Map.Entry<String, Integer>::getValue)).getKey();
// 或者
max = Collections.max(
        map.entrySet(),
        Map.Entry.comparingByValue()).getKey();
// 这些其实没什么必要,面试时记不住就用循环解决

// string
String str = "aaa bbb ccc";
String[] parts = str.split(" ");      // {"aaa", "bbb", "ccc"}
String[] parts1 = str.split(" ", 2);  // {"aaa", "bbb ccc"}
String[] parts2 = str.split(" ", -1); // -1的意思是不要删除最后面的空字符

另外,Java的Collection由于只接受对象形式的元素(就是说不能用基本数据类型的元素),加上其维护代码的开销,性能往往比数组差。比如int[]比ArrayList<Integer>要快,尤其是在比拼算法所用时间的情况下尤为明显。

Stack

“Stack”是Java中关于栈的旧API。现在应该使用Deque。LIFO。

Deque<String> stack = new ArrayDeque<>();
stack.push("abc");
String s1 = stack.peek();
String s2 = stack.pop();

Queue

普通的队列,在Java中一般是以LinkedList的方式实现的。Queue常用于广度优先搜索算法。FIFO。

Queue<String> queue = new LinkedList<>();
queue.offer("abc");       // enqueue
String s = queue.poll();  // dequeue

Priority Queue

Priority Queue常被用来在不排序的情况下取最大的或最小的几个值。如果有n个数据元素,要取前k个(k<n)最小的元素,用排序法的话T=O(n * log2(n)),而用Priority Queue的话,则为T=O(n * log2(k)),这样就能快一些。方法是往queue里逐个放入数据元素,如果queue的长度大于k,则把一个元素移出(也就是最大的那个或最小的那个,取决于Priority Queue的设定方式。这样,剩下的就是k个最小的或最大的元素。

Priority Queue的时间复杂度:enqueue和dequeue操作为O(log2(k)),查看(peek)最前面的元素为O(1)。

PriorityQueue<Elem> queue = new PriorityQueue<>(Comparator.comparingInt(Elem::getValue)); 

for (Elem elem : list) {
    queue.offer(elem);
    if (queue.size() > k) {
        queue.poll();
    }
}

上面的代码中的queue.poll()会把Elem.getValue()返回值最小的元素移出。要想移出最大的,则改为Comparator.comparingInt(elem -> -elem.getValue())。

另一个应用的例子:合并k个有序列表。一般的算法为取各列表的第一个元素,找到最小的那个,加入新列表,然后把最小元素的下一个元素作为它原所属列表的第一个元素。不断重复,直到用完所有列表的元素。这样做的话,T=O(N * k),其中N为所有列表中的元素总数,k为列表个数。因为每次从k各元素中取最小值为O(k),而对所有元素都要进行一次这样的操作,故为O(N * k)。

可以用Priority Queue进行优化:把各列表的第一个元素加入queue;取出最前面的元素加入新列表,并把它的下一个元素加入queue。这样的话,每次enqueue和dequeue操作为O(log2(k)),对所有元素做这种操作,总体的时间复杂度T=O(N * log2(k)),比上述的O(N * k)要快。

public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) {
        return null;
    }

    if (lists.length == 1) {
        return lists[0];
    }

    PriorityQueue queue = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));

    for (ListNode node : lists) {
        if (node == null) {
            continue;
        }

        queue.offer(node);
    }

    ListNode dummyHead = new ListNode(0);
    ListNode prev = dummyHead;

    while (!queue.isEmpty()) {
        ListNode node = queue.poll();

        prev.next = node;
        prev = node;

        if (node.next != null) {
            queue.offer(node.next);
            node.next = null;
        }
    }

    return dummyHead.next;
}

题外话:另一种十分简单的解法是Divide & Conquer:合并两个有序列表(按第一种思路)的时间复杂度是O(n),n是两个列表中的元素总和。可以把k个列表两两分组,并将其合并成一组更长的列表;然后继续两两分组合并,直到最后只剩一个列表为止。这看起来跟体育比赛分组类似,没什么特别的,但观察一下的话,一共有log2(k)轮的分组,每轮都要遍历所有N个元素,因此,时间复杂度也是O(N * log2(k))。

public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) {
        return null;
    }

    if (lists.length == 1) {
        return lists[0];
    }

    int i = 0;
    List mergedList = new ArrayList<>();

    // 两两分组合并。merge2()的作用是普通的合并两个有序列表。
    while (i < lists.length) {
        ListNode node1 = lists[i];

        if (++i < lists.length) {
            mergedList.add(merge2(node1, lists[i++]));
        } else {
            mergedList.add(node1);
        }
    }

    return mergeKLists(mergedList.toArray(new ListNode[0]));
}

Map

HashMap经常用来提高程序的效率。写入(put)、查找(contains)和取值(get)都为O(1)。这里有一些简单写法:

// 计数:记录相同词语出现的次数
Map<String, Integer> map = new HashMap<>();

for (String s : list) {
    map.merge(s, 1, Integer::sum); // 如果没有,值设为1;否则,在原来的基础上加1
}

// map的值为一个列表。如果不存在,创建列表。
// key为字符串,value为储存字符串索引位置的列表
Map<String, List<Integer>> map = new HashMap<>();

for (int i = 0; i < list.size(); ++i) {
    map.computeIfAbsent(list.get(i), k -> new ArrayList<>()).add(i);
}

Bitwise & Bit Shift Operations

// Unary bitwise complement (~)
int n = ~5;  // ~0101 --> 1010

// Bitwise AND (&)
int m = 0b0001;
int result = n & m;   // 1010 -> 0000
// Bitwise inclusive OR (|)
result = n | m;   // 1011

// Bitwise exclusive OR (XOR) (^)
result = n ^ 0b1111;   // 1010 --> 0101

// Signed left shift (<<): shift once results integer x 2
result = 8 << 1;  // 16
result = -8 << 1; // -16

// Signed right shift (>>): shift to right and fill 0 on left, but keep leftmost bit.
// shift once results integer / 2
result = 8 >> 1;  // 4
result = -8 >> 1; // -4 

// Unsigned right shift (>>>): shift to right and fill 0 on left.
result = 8 >>> 1;  // 4
result = -8 >>> 1; // 2147483644

一些常用的用法:

// 乘或除2的n次方
int result = 3 << 4;  // 3 * (2 ^ 4)
result = 32 >> 3;     // 32 / (2 ^ 3)

// 判断奇偶
boolean isEven = (123 & 1) == 0;  // 注意由于优先级的原因,应使用括号

// 判断某位是否为1(比如每个位表示一种flag)
int mask = 0b0010;
boolean isSecondBitFromRightSet = (7 & mask) == mask;  // 7 = 0b0111, 7 & mask = 0b0010

// XOR:
// a XOR a = 0
// a XOR 0 = a
// a XOR b XOR c = a XOR (b XOR c)
// a XOR b XOR b = a

Trie

Trie又名“前缀树”,用于根据前缀查找词汇。典型的应用包括:自动完成、拼写检查、IP路由、解字谜等等。哈希表虽说查找的时间复杂度为O(1),但在查找相同前缀的词汇时就缺乏效率了。而且如果输入了大量的词汇的话,还有因哈希冲突而导致的效率下降。

如果输入的词汇数量为n,查找的前缀长度为m,那么查找的时间复杂度为O(m)。

这里是一个简单的实现:

public class Trie {
    public static class Node {
        private String word;
        private final Map<Character, Node> childrenMap = new HashMap<>();

        public Node() {
            // empty
        }

        public boolean hasChild(char c) {
            return childrenMap.containsKey(c);
        }

        public Node getChild(char c) {
            return childrenMap.get(c);
        }

        public Node addChild(char c) {
            return childrenMap.computeIfAbsent(c, k -> new Node());
        }

        public void setWord(String str) {
            this.word = str;
        }

        public String getWord() {
            return word;
        }
    }

    private final Node root = new Node();

    public Trie() {
        // empty
    }

    // 输入一个词汇
    public void insert(String word) {
        Node node = root;

        for (char c : word.toCharArray()) {
            node = node.addChild(c);
        }

        node.setWord(word);
    }

    // 查看是否有该词汇
    public boolean search(String word) {
        Node node = root;

        for (char c : word.toCharArray()) {
            node = node.getChild(c);

            if (node == null) {
                return false;
            }
        }

        return node.getWord() != null;
    }

    // 查看是否有以该前缀开始的词汇
    public boolean startsWith(String prefix) {
        Node node = root;

        for (char c : prefix.toCharArray()) {
            node = node.getChild(c);

            if (node == null) {
                return false;
            }
        }

        return true;
    }
}

如果词汇的组成字符是有限的(比如小写英文单词由26个字母组成),另一种实现方法是用一个数组代替Trie.Node中的HashMap。我个人不太喜欢这种方法。但是用Java语言的时候,讽刺的是这种方法往往能快一点点。

public static class Node {
    private static final int N = 26;    // 字符总数
    private Node[] links = new Node[N]; // 查找时用索引 int i = c - 'a';

    // 用flag代替上面的字符串能节省点空间。
    // 用字符串的目的是在需要得到词汇时不用从头遍历到目前结点,而是能直接拿到
    private boolean end = false;

    // ...

    private int getIndex(char c) {
        return c - 'a';
    }

    public boolean contains(char c) {
        return get(c) != null;
    }

    public Node get(char c) {
        return links[getIndex(c)];
    }

    // ...
}

 

 

深度优先遍历和广度优先遍历

深度优先遍历(DFS)和广度优先遍历(BFS)是两种遍历或搜索树和图的方法,常用于,呃,面试中……

深度优先遍历(DFS:Depth First Search)

DFS的遍历顺序又分为三种。下面以N代表当前结点,L代表左侧子结点,R代表右侧子结点。

  • 先序(preorder):先处理当前结点,然后对子结点从左向右处理。NLR,从上向下,从左向右
  • 中序(inorder):先处理左侧子结点,然后处理当前结点,最后处理右侧子结点。LNR,左–>当前–>右
  • 后序(postorder):先从左向右处理子结点,然后处理当前结点。LRN,从下向上,从左向右

如果把表达式放入二叉树,那么先序遍历的结果是前缀表示法(波兰表示法,Polish Notation),中序遍历的结果是中缀表示法,后序遍历的结果是后缀表示法(逆波兰表示法,Reverse Polish Notation)。

DFS的特点天然地适合使用递归的方法实现。三种顺序仅仅通过改变处理程序片段的位置就能实现,十分简单易行。用循环来实现的话,就得自己维护一个栈,而且实现起来也比较麻烦。

private void dfsInorder(Node node) {
    if (node == null) {
       return;
    }

    // 处理左子结点
    if (node.left != null) {
        dfsInorder(node.left);
    }

    // 处理本结点
    handle(node);

    // 处理右子结点
    if (ndoe.right != null) {
        dfsInorder(node.right);
    }
}

private void dfsPreorder(Node node) {
    if (node == null) {
       return;
    }

    // 处理本结点
    handle(node);

    // 处理子结点
    if (node.left != null) {
        dfsPreorder(node.left);
    }

    if (ndoe.right != null) {
        dfsPreorder(node.right);
    }
}

private void dfsPostorder(Node node) {
    if (node == null) {
       return;
    }

    // 处理子结点
    if (node.left != null) {
        dfsPostorder(node.left);
    }

    if (ndoe.right != null) {
        dfsPostorder(node.right);
    }

    // 处理本结点
    handle(node);
}

循环的方法:估计面试时不会要求做这个吧?其中,前序比较容易,而中序比较难,后序本来更难,但鉴于它与先序的对称性,可以把结果按先序(但是改为从右向左)压入栈中,再反向逐个弹出即可。

public void dfsPreorder(Node root) {
    if (root == null) {
        return;
    }

    Deque<Node> stack = new ArrayDeque<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        Node node = stack.pop();
        handle(node);

        if (node.right!= null) {
            stack.push(node.right);
        }

        if (node.left != null) {
            stack.push(node.left);
        }
    }
}

public void dfsInorder(Node root) {
    if (root == null) {
        return;
    }

    Deque<Node> stack = new ArrayDeque<>();
    Node node = root;

    while (node != null || !stack.isEmpty()) {
        if (node != null) {
            stack.push(node);
            node = node.left;
        } else {
            node = stack.pop();
            handle(node);
            node = node.right;
        }
    }
}

public void dfsPostorder(Node root) {
    if (root == null) {
        return;
    }

    Deque<Node> stack = new ArrayDeque<>();
    Deque<Node> reversedStack = new ArrayDeque<>();
    stack.push(root);

    while (!stack.isEmpty()) {
        Node node = stack.pop();
        reversedStack.push(node);

        if (node.left != null) {
            stack.push(node.left);
        }

        if (node.right != null) {
            stack.push(node.right);
        }
    }

    while (!reversedStack.isEmpty()) {
        handle(reversedStack.pop());
    }
}

广度优先遍历(BFS:Breadth First Search)

BFS的访问顺序一般是从左向右,从上向下,一层一层地处理。一般用循环通过某种容器(queue、stack、set等)来实现。

public void bfs(Node root) {
    if (root == null) {
        return;
    }

    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);

    while (!queue.isEmpty()) {
        Node node = queue.poll();
        handle(node);

        if (node.left != null) {
            queue.offer(node.left);
        }

        if (node.right != null) {
            queue.offer(node.right);
        }
    }
}

有时候在做BFS时,需要在处理完每一层后,都做一些其他的处理。这种情况下,可以在while循环中再套一个循环来实现:

public void bfsPerLevel(Node root) {
    if (root == null) {
        return;
    }

    Queue<Node> queue = new LinkedList<>();
    queue.offer(root);
    int count = 0;

    while (!queue.isEmpty()) {
        int n = queue.size();

        for (int i = 0; i < n; ++i) {
            Node node = queue.poll();
            handle(node);

            if (node.left != null) {
                queue.offer(node.left);
            }

            if (node.right != null) {
                queue.offer(node.right);
            }
        }

        // 此处加入处理完当前层后的代码
        log.info(">>> end of level {}", count++);
    }
}

一般来说,递归跟栈相关,所以对于DFS这种递归属性的算法,可以在循环中用栈来实现。而对于BFS这种跟栈没关系的算法,一般人就会认为不能用递归实现。但实际上还是有办法做到的:

private void recursiveBfsHelper(List<Node> nodeList) {
    if (nodeList.isEmpty()) {
        return;
    }

    List<Node> nextList = new ArrayList<>();

    for (Node node : nodeList) {
        handle(node);

        if (node.left != null) {
            nextList.add(node.left);
        }

        if (node.right != null) {
            nextList.add(node.right);
        }
    }

    recursiveBfsHelper(nextList);
}

public void recursiveBfs(Node root) {
    recursiveBfsHelper(Collections.singletonList(root));
}

DFS算法实现起来简短方便(用递归的话),因此十分适合在面试中使用。但有些问题则需要用到BFS,比如求迷宫最短路径问题(因为逐层扫描直到找到解为止,这个解一定是最优解(或最优解之一),这里的最优解是路径最短的意思。

例题:二叉树的序列化和反序列化

将二叉树序列化为一个字符串,并将字符串反序列化为二叉树。

  • 需要产生压缩的结果,否则对于只有右子树的二叉树,结果会过长。
  • 由于上述要求,用DFS比较恰当,因为产生的结果便于反序列化。
    // 序列化

    // DFS preorder
    private void dfs(TreeNode root, StringBuilder sb) {
        sb.append(",");

        if (root != null) {
            sb.append(root.val);
            dfs(root.left, sb);
            dfs(root.right, sb);
        }
    }

    // Encodes a tree to a single string.
    public String serialize(TreeNode root) {
        if (root == null) {
            return "";
        }

        StringBuilder sb = new StringBuilder(root.val);
        dfs(root.left, sb);
        dfs(root.right, sb);

        return sb.toString();
    }

    // 反序列化

    private TreeNode dfs(Queue<String> queue) {
        TreeNode root = null;

        if (!queue.isEmpty()) {
            String s = queue.poll();

            if (!s.equals("")) {
                root = new TreeNode(Integer.parseInt(s));
                root.left = dfs(queue);
                root.right = dfs(queue);
            }
        }

        return root;
    }

    // Decodes your encoded data to tree.
    public TreeNode deserialize(String data) {
        if (data == null || data.length() == 0) {
            return null;
        }

        Queue<String> queue = Arrays.stream(data.split(",", -1)
                .collect(Collectors.toCollection(LinkedList::new));

        return dfs(queue);
    }

回溯算法

回溯算法是一种可以找到全部或部分解的通用算法。跟暴力穷举法相比,由于可以对搜索域剪枝,因此效率要高。所谓回溯,就是走到某处后如果发现此路不通,会立即退回到上一步,而不是像暴力法那样仍不管不顾地都试一次。因此,回溯法是一种选优搜索法,又称为试探法。

回溯算法的编写格式:

boolean backtrack(candidate) {
    // 递归终止条件
    if (isSolution(candidate)) {
        output(solution);
        return true;
    }

    // 标记当前结点(比如把当前结点标记在路径里)
    place(candidate);

    // 遍历和尝试所有下一级的结点
    for (nextCandidate : listOfCandidates) {
        // 剪枝:对于不合适的结点,直接跳过
        if (isValid(nextCandidate)) {
            // 继续深入到下一层。如果最终能找到解,返回;否则尝试下一个下级结点
            if (backtrack(nextCandidate)) {
                return true;
            }
        }
    }

    // 清除当前结点的标记(比如把当前结点从路径上移除,这意味着从其他结点还能继续来这里)
    remove(candidate);
    return false;
}

说明:

  • backtrack函数是一个调用自己的递归函数,所以要有终止递归的条件
  • 里面有一个循环,用于尝试所有下一级的结点。比如迷宫问题,当走到了某点后,继续尝试相邻的三个结点(前一个结点除外)。循环里应该有剪枝,避免不必要的递归调用开销。
  • 经常需要标记当前结点,并在尝试完从这里开始的所有可能性后清除标记。(比如,不要重复访问已标记的地方)

例题:N皇后问题(八皇后问题)

暴力穷举法:把第一个皇后放置在第一行的每个位置,然后搜索所有的解(尝试把第二个皇后放在其他所有位置,再尝试把第三个皇后放在其他所有位置……)。其实这已经不太“暴力”了,因为完全的“暴力搜索”应该是把第一个皇后也放在棋盘的所有位置,最后再去掉重复的解。

回溯法:把第一个皇后放在第一行的每个位置,由于第一行肯定不能再放置皇后,因此,尝试把第二个皇后放在第二行的每个位置,判断一下是否满足条件,把不满足条件的跳过,然后再在第三行尝试放置第三个皇后……

进一步优化:保存一个长度为N的数组,在某一列放置过皇后的话,就把相应的数组位置设置为true。这样,在下一行放置下一个皇后时,先跳过所有置为true的各列,然后再判断某位置是否符合条件(因为还有斜向的攻击)。在退出前从数组中把其列标志清除。

总结:女人真可怕……我本人的话,性格应该跟“车”比较相似。

颠覆传统的理财观念

施升辉是台湾的投资理财类书籍的作者。我在网上听了他的一个讲座(下文称施先生为“作者”,尽管我没读过他的书),以及观看了他的几个电视访谈节目,觉得他的观点很简单易行,但同时又比较有启发性。下面是一些笔记:

投资可以无脑、佛系,只要追求稳健的投资即可,而且也应该这样做。

投资应该精简。作者简化到两支股票(iDog:根据他的其他节目里的发言,应该是0050和0056这两支ETF)。不要什么投资都做,而是要集中、专注到自己所擅长的项目上。

认命才能赚钱。在股市赚5%很容易,但如果希望赚20-30%,则结果很容易赔钱。(iDog:想赚那么多,就得做风险比较高的投资,这样当然也就容易赔钱了。)大家都把钱存在银行,说明相信银行。既然如此,为什么不去买这个银行的股票呢?银行股一般比较稳健,且股息率高,比如5%。(iDog:这里有点问题。相信银行是一般不懂金融的人,懂的是相信政府对银行的扶持,比如银行会有保险,在一定金额以下,如果银行倒闭,会用保险金来还清客户的储蓄。而且,通过股息赚5%,不等于说就是在股市赚了5%,因为还有股价的起伏。但这属于“微瑕”,对一般人来说还是有启发意义的。)要通过领股息的方法投资股市的话,找那些大到不会倒闭的公司,并且长期坚持支付股息的,投资那些股息回报率比较高的,比如高于5%的那些。(iDog注:其实领股息的投资方法不见得是最优的,因为股息本身就是税后的了,比巴菲特的公司那种不发股息的公司效率更差些,除非公司不会善用这部分钱。但是,对普通人来说,按这个思路投资,会起到两种效果:一个是敢于在股市大跌的血雨腥风的时候入场买,另一个就是实现了buy-and-hold的策略。)

股票是凡夫俗子唯一可以投资的项目。但是关键在于安于比较现实的回报率,不要好高骛远。

定期存款:只存两年的生活费,其他的钱用于投资。两年生活费的储蓄是为了防止股市大跌的时候因缺钱而不得不卖出股票。

一些金融工具不是用来赚钱的,比如旅行保险(没人希望飞机掉下来去赚这笔钱)。第一栋房子就不是用来赚钱的,而是用来住的。

买股票的一个标准是:买入后晚上能睡得着觉。套牢是投资人的宿命,所以不要怕被套牢,而是要拥抱套牢。指数ETF或者高股息ETF也是不会倒闭的。对于不会选股的人,可以投资这些。对于台湾市场来说,市场联动ETF为0050(目前较高,在合适的时候买或用美元平均法定投)和0056(高股息的30家企业组成的,价格波动十分无聊,但股息回报率可达到6%)。

“出来混迟早是要还的”。在股市大跌的时候,要勇敢地冲进去买。买了当然有可能套牢,但套牢时投资人的宿命,所以不要怕。为了降低风险,就要选择套牢了也不用太害怕的投资目标,比如不会倒闭的大公司,或者大盘指数。0050(台湾Top 50公司,相当于道琼斯指数)适合波段操作,在KD为20时增加头寸,达到80时减小头寸。0056(台湾高股息股票Top 30)可以“存股”,即长期持有。

iDog注:在一次访谈节目中,施老师和“不败教主”陈重铭老师同台。陈老师对DK的20/80策略做修正如下:

  • 盘整行情:按日线图,DK<20时买;DK>80时卖
  • 上升行情:日线DK<20时买;周线DK>80时卖
  • 下降行情:周线DK<20时买;日线DK>80时卖

原因:股价上升的时候,买入的机会不会太多,因此应该看日线DK来买入,否则看周线DK的话,恐怕根本没有机会;在卖出时,则看周线DK来尽量不要轻易地卖出。下降行情同理。盘整行情则最适合做这种波段操作,故日线即可。施老师的回应:比如下降行情中,日线DK>80的时候,有可能还是处于套牢状态,在这种情况下,他是不会卖的,毕竟还有股息可领。陈老师表示同意。

附录:投资者贱芭乐的DK操作法:他是采用波段操作,主要看周线DK。这样,一方面不需要像看日线那样必须每天盯着看,另一方面又不用像看月线那样一年交易不了一两次。看周线的话,完成一次买卖大约需要四五个月。

  • 选股范围:从0050的50支股票里选那些优秀公司的股票,防止出现风险过大的情况(比如公司倒闭等)
  • 交易方法:按周线图的DK来交易:处于低位出现金叉时买入,高位出现死叉时卖出。有时候会出现DK线条纠结在一起的情况,金叉和死叉频繁出现。在出现这种不明确的情况时,就不要操作这支股票。

iDog:关于存股:多年前在日本混的时候,我广泛涉猎各种投资书籍杂志,读到过这种思路。就是跟存钱一样,改为存股票。也就是对投资人来说,不是把注意力放在股票价格上,而是股票数量上。这里有以下的道理:

  • 存钱vs存股:存钱会受到通货膨胀的侵蚀,也就是说,表面上看起来是零风险,实质上是100%的贬值风险。而存股票的话,在宏观上看是可以抵御通货膨胀的,而且还可以享受到总体经济发展带来的成果。当然,微观上看,会有个股和某些个时间点上的账面损失。
  • 注重股票的价格vs股票的数量:我们都知道,股票的价格只是大家认为它值那么多钱,而不是它的真正价值。因此,股票的价格是比较主观的,感性的,和缺乏理智的。而股票的数量则真正反映了你占有的资源的多少。因此,与其在股市动荡的时候跟着时喜时忧的,不如“宠辱皆忘”,一门心思地增加自己持有的股票数量。当然,这里有个问题要注意:股价不是完全的没有道理,如果公司的业务出现问题,股价一定会下降的。此时,是不是要乘机买入更多的股票(或者出清手里持有的股票),主要取决于该公司能否克服这个问题。如果不管三七二十一地一味低价买入,那么公司倒闭了的话,只能是一大把的毫无价值的废纸。因此,用“存股”的策略时,应该存那些稳健的股票或指数。

作者本人以前在证券公司工作,后来由于市场和业绩情况被裁员。之后为自己投资时,为了追求较高的回报率而使得投资组合的风险较大,且很容易落后于大盘。他意识到,要想睡好觉,只要跟大盘同样即可。因此,他就把股票都卖掉,换成了指数ETF。巴菲特对普通人以及他自己的夫人的投资建议也是买指数基金。作者现在每月平均可拿到6-7万元的股息,子女也已成年,故生活悠闲,自称“乐活大叔”。

他的建议是,只要配息超过5%,并且不会倒闭(比如0056这种指数ETF),就可以放心大胆地买入并持有。假如这些都能崩溃的话,新台币本身也会不再有任何意义,因此不需要担心。(iDog注:货币就是某种大众全员的信仰,否则,则不过是废纸而已。这种想法让我想起在日本生活时的事情。如果你买房子的时候问房产经纪:如果发生大地震倒塌了会怎样呢?他一般会告诉你:如果这种情况发生的话,肯定不只是你自己的房子甚至你这栋楼倒塌,而是一个大范围的倒塌,因此不用担心。也就是说,有点法不责众的感觉。如果某ETF里的公司普遍倒闭的话,当然基本上意味着整个经济体都要崩溃了,因此,有钱没钱基本上都没什么区别了。当然其实,还是可以通过拥有黄金和海外资产等方式实现风险分散,从而在一定程度上抵御这种无法抗拒的灾难的。所以说,如果只是着眼于比如台湾等某一个地区的金融市场的话,格局还是小了一点儿。:-) )

他在1990年,把股市里赚到的钱用于在台北市买房,也因此而避开了之后的股灾,所以才能有今天的生活。几年前他又买了一套房(不在台北),为了抵御老年介护的风险(需要介护时卖掉房子,否则就作为资产存下来)。

给年轻人的建议:每月从工资里必须拿出6000元(新台币),一年就有72,000元,可以买3000单位的0056。这样持续6年,就可获得500,000元。如果是年轻夫妻的话,二人共有100万元,可算是人生的第一桶金。一个人的话,每月拿出1万元,6年后也会有100万元。此时,可作为首付在台北的周边地区买房(台北市太贵)。对于第一套房子的观点是,把它看作是生活必需品,而不是投资物件。不要买太贵的,比如年轻人不需要有电梯的楼房,而且在新北市的话也不需要养车(不用买车位)。买了房子后,有钱不要急着还清房贷(因为利率相对很低),而是继续之前的投资。

对房子的观点:目前台北地区显然租比买合算(iDog:我觉得几乎所有华人多的地区都是如此),但60岁以上的人,房东多不愿意继续租(iDog注:我想主要有如下因素:老年人由于没有工资收入,有付不起房租的风险;有在房子里死亡的风险,对今后出租有负面效果。),因此,如果能保证在60岁时能买得起房子的话,就不需要早买了,否则,为了以防万一,还是应该尽早买一个比较安心。第一个房子就是用于自住,能买得起最重要,而不是传统的“location;location;location”的条件(iDog注:三个location的原则主要是对于投资性的房地产。对自住房没那么重要,相反,你喜欢住哪里更重要一些。比如交通便利的地方地脚好,但你可能嫌吵。)。对于年轻人来说,离车站远一点,没有电梯,不要停车场,价格就会便宜下来,在新北市,还是能找到1000万以下的房子的。(iDog:以东京来比较的话,1000万新台币——也就是大约4000万日元——的房子算是比较大和比较好的了。看来大台北地区的房子还是十分贵的。)按照经济学规律,少子化会带来房价的下降。买房子就是为了以防万一。

给退休人士的建议(假设有自己的房子,子女已成年):夫妻二人生活费每年50万元(没有出国旅游的情况下足够),资金需要1100万元。配置方法:1000万元投资股息率5%的股票,用于赚取每年50万元的生活费;100万元为生活紧急预备资金,相当于两年的生活费,在遇到股灾的时候避免不得不卖出股票。

投资理财的道路上,不要相信任何人,包括自己的爸爸。自己的爸爸当然不会害自己,但他的观点就一定对吗?(iDog注:我也是这样认为的,有时候在论坛上会见到年轻人问自己积攒下的多少加元应该怎样投资的问题,我都会说要去学习投资,直到自己清楚该怎样投资为止,而不要随便相信任何人的建议。我不但不会相信任何人,而且连非指数型的基金都不信任。这都是在日本时通过大量的涉猎学到的。现在国内似乎私募、基金、ETF等比较热,我也时而被推荐买入这些,我都是不假思索地谢绝。实际情况是,只有自己才不会欺骗自己的,顶多是学艺不精,但在这种情况下可以投资指数啊。)

2008年到2020年间,没有出现过股灾(iDog注:网上经常有下跌了区区的2%左右就称为“断崖式下跌”的,这属于一惊一乍型,或者外行硬冲内行型。但2020年3月的跌幅超过了20%。),但股灾一定会出现的,尽管没人知道什么时候会出现。一些年轻人比较急功近利,追求高回报,这样就难免趋向于投机。这样的话,如果碰到一次股灾,可能就会把以前努力赚到的那些钱都赔光。因此,要强调稳健型投资,原则是“稳稳赚,慢慢赚,久久赚。”(iDog注:巴菲特说投资最重要的就是不赔钱,跟这里说的是一个道理。虽说不赔钱比较难,但我们至少可以做到少赔钱。)

不要过度相信长期的复利效果。如果每年赚10%,20年后会变成原来的6.72倍。但这里有个假设是长期的每年都赚10%,这是不容易的。如果下跌了50%,要涨回去就得涨100%,而要涨到下跌前的金额加10%,就需要涨120%。由于股灾总会到来,因此不要过度相信长期的复利效果。如果股市里赚了钱,就要把钱用掉,因为用掉的钱才是自己的(iDog注:这可能是个哲学问题。这样说的人的主要想法是:钱不过是一个数字或者一堆没有价值的纸,只有换成有价值的东西才算是有意义。其实,这也不过是换了一种存在形式而已。比如,换成房子或者黄金,也不过是某种有形的东西,某种可以用货币来衡量的东西。因此,没什么本质的区别,而且有形的东西保存起来还麻烦,不如账户里的金钱不过是一串数字,不需要自己特意去保存。另,卖掉的股票应当看作是某家公司的一部分权益,因此,与之相比,房子或黄金不见得更有意义。当然,如果把金钱投资于自己,比如学习新技能等等,则是另一回事。这样可能是真的有意义了吧?)。快速用掉钱的方法之一就是买房地产,而且房子是相对保值的,不像股票那样起起落落。(iDog注:一般股市大涨的时候,房地产市场往往不会太热,在股市疯狂的时候,国内时有“卖房炒股”的人。因此,股票投资如果赚了钱,卖掉一些股票并买房子的话,就相当于对投资组合的再平衡。)卖掉股票后,不要用储蓄的形式保存财富,因为有通货膨胀的因素。房地产是比较好的方式之一。

投资理财越简单越好,精力可以用于生活中更有意义的事情上。作者本人由于有房子,而且被动收入足够日常开销,因太闲而到大学里去学习写剧本。

以被动收入生活要满足两个条件:

  • 买必要的保险。没有保险的话,比如生一场大病,就会让被动收入化为乌有。(iDog注:指那些真正的保险,不是那种以理财为名义的莫名其妙的保险。)
  • 有自己的房子。如果租房子,万一被房东赶出去(iDog注:这说明台湾的相关法律对租客的保护不太充足?),就面临必须自己去买房子的问题,这样,被动收入就化为乌有(iDog注:这里应该是指买房子需要投入一大笔钱,这样就消减了自己赚取被动收入的资产规模,因此被动收入就会减少甚至消失。但是,总可以去找其他房子租吧?可能是指老年人,不容易租到房子的情况吧。)。要把保险费看作扔掉的钱(iDog:破财免灾),不要用保险的形式投资。保险种类的广度要大(就是各种情况都保到),但深度不用太大(否则开销太大)。

另,作者似乎是在2003年时被公司裁员(当时市场正是低点),而那也是我作为一个年轻人开始对投资理财产生兴趣的一年。之前则一直是关心IT技术和修行等等,对股票持负面态度(当时正值网络泡沫崩溃的时候,电视里也总是一些耸人听闻的市场下跌的消息。),后来可能是出于家庭责任感而开始逐渐思想变得成熟了吧。人生就是有各种各样的际遇。

加拿大投资理财之储蓄篇

刚到加拿大时,除了碧蓝的天空、明亮的阳光、远处冠雪的群山和高耸直立的树木外,让我同时强烈感觉到的东西就是极差的服务——这里的差不是说服务人员的职业素质差(当然相当一部分人的确如此),而是这里金融、电信等行业本身提供的服务太差。记得多年前第一次来的时候,想先到银行开个账户,以便把一些资金汇进去存着,用于旅游和生活(因为第一次来的时候属于“短登”,日本那边还有一份工作等着我回去),结果进银行一看其手册,满眼都是各种的收费。我从来没想过抢银行,结果银行却想要抢劫我了……于是吓得掉头就溜走了。直到几年前正式硬着头皮要在这里定居时,才不得不开了个账户,并办了个手机卡。因为这种种的事情,让我简直得了抑郁症(从没想过自杀等,只是做什么都提不起精神来,觉得没什么意义。就好比刚从高速下来,就进入一个严重堵车的街道。)。但认识到自己不够“看破、放下”,就既来之则安之了,也对金钱不像以前那么执著了:赚着少得多的钱,缴纳着高得多的税率的税,用着贵得多、差得多的服务,环绕着很多素质差得多的人(以前在Metrotown住,周围很多奇奇怪怪的人。有好多次过马路的时候都险些被车撞,比我这辈子在其他地方遇到的加起来都多。更不用说到处吸烟吸毒的了)。对服装也越来越不在意,只要足以蔽体即可。现在由于新冠肺炎的影响而意外地暂时实现了梦寐以求的远程(在家)工作,更是常常只要一套睡衣就可以了。发型也充满禅意,达到了“空”的境界。

由于处于这样一种状态,实在提不起精神来在这里进行任何投资,更不用说写关于投资的东西了。可是,不管这里条件多差,日子终究还是要过的,所以在努力了几年(!)之后,也逐渐地让一切恢复了正常。就算投资理财远远比不上在其他国家做,终究比不做还是要好些,聊胜于无。尤其是在去年10月从熙熙攘攘的本那比搬家到了人烟相对稀少的穆迪港,之后不久,恐怖的新冠肺炎就突然爆发,并且一发不可收拾。此时,马上意识到了这里的可贵之处。毕竟钱财等都是身外之物。如果说身体都不重要的话,身外之物就更不值一提了。

这里的金融服务水平极差,条文多到自己的员工都搞不太懂,而且多是对客户不利的条款。以前的我会仔细通读,来这里后,处于抑郁状态中的我实在觉得读起来很恶心,就懒得去理会了。但越是这样,就越是要遭受不必要的损失。因此,这里就把这些乱七八糟的东西记录下来,不但可供自己以后复习用,而且也可能会让其他的到加拿大的新移民或对理财不感兴趣的老移民甚至公民(如果懂中文的话……)受益。

这里首先说一下前面提到的银行,其他的内容以后会在其他文章中讲述。这里在银行开个账户,就会发现有各种名目的收费。我是到RBC开的账户(没什么特别的理由,而且天下乌鸦一般黑),跟老婆一起开的联名账户。联名账户的好处是一般来说二人中任何人都可以存款取款,但有些手续好像就得两人都到场了。对于居家生活,如果财务透明的话,这样把钱都放在一个地方,会比分开放在两个地方要方便得多。

这里的银行会收各种的“苛捐杂税”:每月要收月费;每月取钱次数多了每次要收费;转账要收费;用支票要收买支票的费用……等等不一而足。刚开始时,可能是因为RBC对初来乍到的移民的政策,加之银行里华人顾问的帮忙,银行暂且不收月费。但过了大约半年以后,就开始每月收几加元的费用了。虽说没多少钱,但只是因为我有个账户,而且当时还颇有一些存款,却不但没什么优待反而要每月交钱,感觉十分不爽(属于对于赏罚倒挂的憎恶吧)。我曾经问过,说是有投资的话就能免费。我试着在网上弄了个投资账户,但显然没弄对,因此继续被每月扣钱。我由于抑郁的心情,也懒得去管。而且客服也不给力,总是弄不明白。直到我去年因为自己设立了一个“皮包公司”,年底想给自己发一些RRSP(这里公司的典型员工福利之一,但后来会计说我的公司不能发这个,就算作普通的员工报酬了),去火速(平时懒得做,在年底最后一个营业日去的)开了一个RRSP账户并存进去2000加元,结果月费就戛然而止了。也就是说,停止月费的最简单的办法就是开个RRSP账户并存入一点点钱(比如100加元即可)。RBC试图让我选择他们打理的投资,但这样一来,又得定期交一些费用,而且我也对投资没有任何的控制。并且是让刚刚毕业的年轻人试图全盘帮我这个有近20年投资经验的人来理财。我当然不愿意上这“贼船”了。这2000加元,直到现在我也什么都没做,就那样放着了。(另,这不是RRSP的最佳方式,我主要的RRSP也并不在这里。之后再讨论RRSP的事情

至于其他的存款,在之前到柜台存支票时,听了柜员的建议而放进了储蓄账户。据说柜员十分乐于提此建议,而我也觉得没错。储蓄账户(savings account)对存款提供更高的利率,有点像我们的定期存款账户(从利率较高这一点来看),但里面的存款没有固定的期限,而且可以随时支取,且对此没有任何的惩罚。与之相对的则是支票账户(chequing account),相当于我们的活期存款账户,只是完全没有利息。平时取款、汇款或开支票,要从支票账户里支取,否则就会被收手续费。而从储蓄账户里把钱拨到支票账户,则是随时可以免费地去做。我很怀疑银行系统把这个本该自动化的过程弄成必须加这样一个额外的手动的步骤,就是想收一点点的智商税。

(跟日本的银行系统比,支票账户实际上相当于“当座預金”账户。只是在日本,这一般是公司用的,而这里却是供普通人用的,作用上相当于“普通預金”账户)

从今年春季开始,各国政府为了对抗因新冠疫情而导致的经济停滞,开始了量化宽松。而对于我的存款来说,直接的后果就是那个储蓄账户每月收到的利息连以前的月费都付不起了(以前大约是月费的两倍多)。这样几个月后,实在是觉得不爽,加之在家里上班导致有更多的时间,我就开始在网上调查了一下,并找到了解决方案。

“富爸爸”系列书籍中,把投资者分为6个层次,其中第五级为“老练的投资者”,其标志之一就是能整合一些投资。虽说进行一些复杂的整合并不是普通人能做的事情,但这里演示一个超级简单的整合却是人人都可以做的,并且会在几分钟之内就立竿见影。

上面提到,由于量化宽松,储蓄账户的利息变得寥寥无几。这是对于诸如RBC这样的大银行来说的。加拿大在金融服务领域虽说远远比不上日本的水平,但还是有网银的。网银由于基本上没有支店、网点,也没有支店所需要的场地和柜台员工,因此,运营成本比普通银行要低得多。而这个对储户不方便的缺点的另一面,就是它们会把多赚的一部分返还给储户,也就是说存款利率更高。当然,诸如月费等也都是免费的。因此,只要把你在大银行里的储蓄账户“搬到”网银,即可收获更多的利息。而大银行的支票账户和网银的储蓄账户之间的连接,可通过eTransfer实现,免费、快速。这就相当于IT领域的客户端——服务器架构,把原来胖客户端的一些逻辑移出并放在远方的服务器上,并把二者通过网络连接起来。对用户来说,跟所有的逻辑都放在客户端相比基本没什么不同。

普通银行账户

普通银行账户

整合普通银行和网银账户

整合普通银行和网银账户

网银虽说看起来比较弱小,且虚无缥缈的,但它们也是正规的银行,存款在一定数额内也是有保险的(CDIC)。保险限度额一般是10万加元,即银行倒闭了也不会波及到限度额内的部分。因此,只要不把太多的钱存进去,就不必担心。

加拿大的网银比较好的主要有EQ Bank和Tangerine两家。经过一些比较,我选择的是EQ Bank,并且也推荐这家。当时我在网上,用了不到5分钟就开好了账户,并把钱汇了进去。点击此连接,开设账户并在一个月内汇入100加元以上的存款的话,会得到大约20加元的奖励(目前是这样,但奖励金额今后有可能变动)。之后就是每个月都多收获一些利息了,以后利率恢复正常的话会获得更多。我这样的宅男都能轻松搞掂,而且只需几分钟就能一劳永逸。所以赶紧行动起来吧!

天广

南有乔木,不可休思。心有灵犀,不可求思。
天之广矣,不可量思。时之永矣,不可穷思。

寂寂山林,言悟其心。何日可归,言见观音。
天之广矣,不可量思。时之永矣,不可穷思。

寂寂山林,言念其佛。何日可归,言见弥陀。
天之广矣,不可量思。时之永矣,不可穷思。